package io.light.frame.dal.mybatis.generator.core.meta.visit.opt;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.TypeReference;
import com.google.common.collect.Maps;
import io.light.frame.dal.mybatis.generator.core.exceptions.MetaAccessException;
import io.light.frame.dal.mybatis.generator.core.exceptions.MybatisGenException;
import io.light.frame.dal.mybatis.generator.core.meta.Dialect;
import io.light.frame.dal.mybatis.generator.core.meta.SupportedDialect;
import io.light.frame.dal.mybatis.generator.core.meta.entity.*;
import io.light.frame.dal.mybatis.generator.core.meta.visit.DialectJdbcTemplate;
import io.light.frame.dal.mybatis.generator.core.meta.visit.MetaAccessor;
import io.light.frame.dal.mybatis.generator.core.util.GenToolKit;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.JdbcUtils;
import org.springframework.jdbc.support.MetaDataAccessException;
import org.springframework.util.CollectionUtils;

import javax.sql.DataSource;
import java.lang.reflect.Type;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.util.*;
import java.util.function.Function;

/**
 * Abstract meta operations
 *
 * @author Ivan
 * @version 1.0.0
 * @date 2021-06-10 02:24
 */
public abstract class AbstractMetaOperations implements MetaOperations, InitializingBean {
    protected final Logger logger = LoggerFactory.getLogger(getClass());
    private volatile static MetaAccessor metaAccessor;
    private final Dialect dialect;

    protected AbstractMetaOperations(Dialect dialect) {
        this.dialect = dialect;
    }

    /**
     * Get {@link DataSource} {@link SupportedDialect}
     *
     * @return {@link SupportedDialect}
     */
    @Override
    public final Dialect getDialect() {
        return dialect;
    }

    /**
     * Lookup {@link DialectJdbcTemplate} to use
     *
     * @return {@link DialectJdbcTemplate}
     */
    protected final DialectJdbcTemplate lookupJdbcTemplate() {
        if (metaAccessor == null) {
            metaAccessor = GenToolKit.beanFactory().getBean(MetaAccessor.class);
        }
        return metaAccessor.lookupJdbcTemplate();
    }

    /**
     * Current database name
     *
     * @return database name
     */
    @Override
    public final String currentDatabase() {
        return lookupJdbcTemplate().queryForObject(buildCurrentDatabaseSql(), Collections.emptyMap(), String.class);
    }

    /**
     * Build sql for find current database
     *
     * @return current database name
     */
    protected abstract String buildCurrentDatabaseSql();

    /**
     * Check database
     *
     * @param database database name
     */
    @Override
    public final void checkDatabase(String database) {
        DatabaseMetaData metaData = fetchDatabaseMetaData(lookupJdbcTemplate());
        ResultSet rs = null;
        boolean exist = false;
        try {
            rs = metaData.getCatalogs();
            List<JSONObject> catalogs = extractResultSet(rs, JSONObject.class);
            if (!catalogs.isEmpty()) {
                for (JSONObject kv : catalogs) {
                    for (Object value : kv.values()) {
                        if (String.valueOf(value).equalsIgnoreCase(database)) {
                            exist = true;
                            break;
                        }
                    }
                }
            } else {
                rs = metaData.getSchemas(database, null);
                exist = rs.next();
                if (!exist) {
                    JdbcUtils.closeResultSet(rs);
                    rs = metaData.getSchemas(null, database);
                    exist = rs.next();
                }
            }
        } catch (MybatisGenException mge) {
            throw mge;
        } catch (Exception e) {
            throw new MetaAccessException(String.format("Fetch database metadata error, database '%s', dialect:'%s'",
                    database, getDialect()));
        } finally {
            JdbcUtils.closeResultSet(rs);
        }
        if (!exist) {
            throw new MetaAccessException(String.format("Unable to get database metadata, database '%s', dialect:'%s'",
                    database, getDialect()));
        }
    }

    /**
     * Get table metadata
     *
     * @param database  database
     * @param tableName table name
     * @return {@link Table}
     */
    @Override
    public final Table table(String database, String tableName) {
        DialectJdbcTemplate jdbcTemplate = lookupJdbcTemplate();
        DatabaseMetaData databaseMetaData = fetchDatabaseMetaData(jdbcTemplate);
        Table table = null;
        try {
            ResultSet rs = databaseMetaData.getTables(database, null, tableName, null);
            List<Table> tables = extractResultSet(rs, Table.class, 2);
            if (CollectionUtils.isEmpty(tables)) {
                rs = databaseMetaData.getTables(null, database, tableName, null);
                tables = extractResultSet(rs, Table.class, 2);
            }
            if (!CollectionUtils.isEmpty(tables)) {
                if (tables.size() != 1) {
                    logger.warn("There are more than one metadata for table '{}' in the database '{}'",
                            tableName, database);
                } else {
                    table = tables.get(0);
                }
            }
            String tableMetaSql = buildTableMetaSql(database, tableName);
            if (table != null && StringUtils.isNotBlank(tableMetaSql)) {
                Map<String, Object> params = Maps.newHashMap();
                params.put("database", table.getDatabase());
                params.put("tableName", table.getName());
                List<TableExtraInfo> tableExtraInfoList = jdbcTemplate.query(tableMetaSql, params,
                        newRowMapper(TableExtraInfo.class));
                if (!CollectionUtils.isEmpty(tableExtraInfoList)) {
                    fillTableByExtraInfo(table, tableExtraInfoList.get(0));
                }
            }
        } catch (Exception e) {
            throw new MetaAccessException(String.format("Extract table metadata error, database:%s, table:%s",
                    database, tableName), e);
        }
        if (table == null) {
            throw new MetaAccessException(String.format("Unable to get table metadata, database:%s, table:%s",
                    database, tableName));
        }
        if (!table.isValid()) {
            throw new MetaAccessException(String.format("Invalid table metadata, database:%s, table:%s",
                    database, tableName));
        }
        //
        //
        table.setColumns(columns(table.getDatabase(), table.getName()));
        return table;
    }

    /**
     * Get table columns metadata
     *
     * @param database  database
     * @param tableName table name
     * @return table columns metadata
     */
    @Override
    public final List<TableColumn> columns(String database, String tableName) {
        DialectJdbcTemplate jdbcTemplate = lookupJdbcTemplate();
        DatabaseMetaData databaseMetaData = fetchDatabaseMetaData(jdbcTemplate);
        List<TableColumn> columns;
        try {
            ResultSet rs = databaseMetaData.getColumns(database, null, tableName, null);
            columns = extractResultSet(rs, TableColumn.class);
            if (CollectionUtils.isEmpty(columns)) {
                rs = databaseMetaData.getColumns(null, database, tableName, null);
                columns = extractResultSet(rs, TableColumn.class);
            }
        } catch (Exception e) {
            throw new MetaAccessException(String.format(
                    "Extract table columns metadata error, database:%s, table:%s", database, tableName
            ), e);
        }
        if (CollectionUtils.isEmpty(columns)) {
            throw new MetaAccessException(String.format("Unable to get table columns metadata, database:%s, table:%s",
                    database, tableName
            ));
        }
        Map<String, TableColumn> columnMap = new HashMap<>(columns.size());
        for (TableColumn column : columns) {
            column.setPrimaryKey(false);
            column.setCanIgnoreOnInsert(false);
            column.setCanIgnoreOnUpdate(false);
            if (!column.isValid()) {
                throw new MetaAccessException(String.format("Invalid column metadata, database:%s, table:%s",
                        database, tableName));
            }
            columnMap.put(column.getName(), column);
        }
        // primary keys
        try {
            ResultSet rs = databaseMetaData.getPrimaryKeys(database, null, tableName);
            List<JSONObject> pks = extractResultSet(rs, JSONObject.class);
            if (CollectionUtils.isEmpty(pks)) {
                rs = databaseMetaData.getPrimaryKeys(null, database, tableName);
                pks = extractResultSet(rs, JSONObject.class);
            }
            if (!CollectionUtils.isEmpty(pks)) {
                pks.forEach(pk -> {
                    String colName = pk.getString("column_name");
                    if (StringUtils.isBlank(colName)) {
                        return;
                    }
                    TableColumn column = columnMap.get(colName);
                    if (column == null) {
                        return;
                    }
                    column.setPrimaryKey(true);
                    column.setCanIgnoreOnUpdate(true);
                    if (column.isAutoIncrement()) {
                        column.setCanIgnoreOnInsert(true);
                    }
                });
            }
        } catch (Exception e) {
            throw new MetaAccessException(String.format("Extract primary keys metadata error, database:%s, table:%s",
                    database, tableName
            ), e);
        }
        // extra info (compensation)
        String columnsMetaSql = buildColumnsMetaSql(database, tableName);
        if (StringUtils.isNotBlank(columnsMetaSql)) {
            Map<String, Object> params = Maps.newHashMap();
            params.put("database", database);
            params.put("tableName", tableName);
            List<TableColumnExtraInfo> columnExtraInfoList = jdbcTemplate.query(columnsMetaSql, params,
                    newRowMapper(TableColumnExtraInfo.class));
            if (!CollectionUtils.isEmpty(columns)) {
                columnExtraInfoList.forEach(extraInfo -> {
                    String colName = extraInfo.getString("column_name");
                    if (StringUtils.isBlank(colName)) {
                        return;
                    }
                    TableColumn existCol = columnMap.get(colName);
                    if (existCol == null) {
                        return;
                    }
                    fillColumnByExtraInfo(existCol, extraInfo);
                });
            }
        }
        return columns;
    }

    /**
     * Build sql for table metadata
     *
     * @param database  database name
     * @param tableName table name
     * @return sql
     */
    protected String buildTableMetaSql(String database, String tableName) {
        return null;
    }

    /**
     * Build sql for columns metadata
     *
     * @param database  database name
     * @param tableName table name
     * @return sql
     */
    protected String buildColumnsMetaSql(String database, String tableName) {
        return null;
    }

    /**
     * Fill table metadata by {@link TableExtraInfo}
     *
     * @param table     {@link Table} Table metadata
     * @param extraInfo {@link TableColumnExtraInfo}
     */
    protected void fillTableByExtraInfo(Table table, TableExtraInfo extraInfo) {
        if (StringUtils.isBlank(table.getComment())) {
            String comment = extraInfo.getString("table_comment");
            if (StringUtils.isNotBlank(comment)) {
                table.setComment(comment);
            }
        }
    }

    /**
     * Fill column metadata by {@link TableColumnExtraInfo}
     *
     * @param column    {@link TableColumn} column metadata
     * @param extraInfo {@link TableColumnExtraInfo}
     */
    protected void fillColumnByExtraInfo(TableColumn column, TableColumnExtraInfo extraInfo) {
        //
        String columnKey = extraInfo.getString("column_key");
        if (StringUtils.isNotBlank(columnKey)) {
            columnKey = columnKey.toLowerCase();
            if (column.isAutoIncrement() && columnKey.startsWith("pri")) {
                column.setCanIgnoreOnInsert(true);
                column.setCanIgnoreOnUpdate(true);
            }
        }
        //
        String colDefault = extraInfo.getString("column_default");
        if (StringUtils.isNotBlank(colDefault) && colDefault.toLowerCase().contains("current_timestamp")) {
            column.setCanIgnoreOnInsert(true);
        }
        String colOnUpdate = extraInfo.getString("column_on_update");
        if (StringUtils.isNotBlank(colOnUpdate) && colOnUpdate.toLowerCase().contains("current_timestamp")) {
            column.setCanIgnoreOnUpdate(true);
        }
        String extra = extraInfo.getString("extra");
        if (StringUtils.isBlank(extra)) {
            extra = extraInfo.getString("column_type");
        }
        if (StringUtils.isNotBlank(extra)) {
            extra = extra.toLowerCase();
            column.setExtra(extra);
            if (extra.contains("default current_timestamp")) {
                column.setCanIgnoreOnInsert(true);
            }
            if (extra.contains("on update current_timestamp")) {
                column.setCanIgnoreOnUpdate(true);
            }
        }
    }

    /**
     * Get database tables metadata
     *
     * @param database database
     * @return tables metadata
     */
    @Override
    public final List<Table> tableList(String database) {
        DialectJdbcTemplate jdbcTemplate = lookupJdbcTemplate();
        DatabaseMetaData databaseMetaData = fetchDatabaseMetaData(jdbcTemplate);
        List<Table> tables;
        try {
            ResultSet rs = databaseMetaData.getTables(database, null, null, null);
            tables = extractResultSet(rs, Table.class);
            if (CollectionUtils.isEmpty(tables)) {
                rs = databaseMetaData.getTables(null, database, null, null);
                tables = extractResultSet(rs, Table.class);
            }
        } catch (Exception e) {
            throw new MetaAccessException(String.format("Extract database tables metadata error, database:%s",
                    database
            ), e);
        }
        if (CollectionUtils.isEmpty(tables)) {
            throw new MetaAccessException(String.format("Unable to get database tables metadata, database:%s",
                    database
            ));
        }
        if (!CollectionUtils.isEmpty(tables)) {
            for (Table table : tables) {
                if (!table.isValid()) {
                    throw new MetaAccessException(String.format("Invalid table metadata, database:%s, table:%s",
                            database, table.getName()
                    ));
                }
            }
        }
        if (CollectionUtils.isEmpty(tables)) {
            return null;
        }
        tables.forEach(table -> {
            String tableMetaSql = buildTableMetaSql(table.getDatabase(), table.getName());
            if (StringUtils.isNotBlank(tableMetaSql)) {
                Map<String, Object> params = Maps.newHashMap();
                params.put("database", table.getDatabase());
                params.put("tableName", table.getName());
                List<TableExtraInfo> tableExtraInfoList = jdbcTemplate.query(tableMetaSql, params,
                        newRowMapper(TableExtraInfo.class));
                if (!CollectionUtils.isEmpty(tableExtraInfoList)) {
                    fillTableByExtraInfo(table, tableExtraInfoList.get(0));
                }
            }
            table.setColumns(columns(table.getDatabase(), table.getName()));
        });
        return tables;
    }

    /**
     * Fetch {@link DatabaseMetaData}
     *
     * @param jdbcTemplate {@link DialectJdbcTemplate}
     * @return {@link DatabaseMetaData}
     */
    protected DatabaseMetaData fetchDatabaseMetaData(DialectJdbcTemplate jdbcTemplate) {
        DatabaseMetaData databaseMetaData;
        try {
            DataSource dataSource = jdbcTemplate.getDataSource();
            databaseMetaData = JdbcUtils.extractDatabaseMetaData(dataSource, meta -> meta);
        } catch (MetaDataAccessException e) {
            throw new MetaAccessException(String.format("Extract database metadata error, dataSource id:%s",
                    jdbcTemplate.getDataSourceBeanId()
            ), e);
        }
        return databaseMetaData;
    }

    /**
     * Extract {@link ResultSet}
     *
     * @param rs   {@link ResultSet}
     * @param type data type
     * @param <T>  data type
     * @return result rows`s data.
     * @throws Exception
     */
    protected <T> List<T> extractResultSet(ResultSet rs, Class<T> type) throws Exception {
        return extractResultSet(rs, (Type) type);
    }

    /**
     * Extract {@link ResultSet}
     *
     * @param rs      {@link ResultSet}
     * @param typeRef {@link TypeReference} data type
     * @param <T>     data type
     * @return result rows`s data.
     * @throws Exception
     */
    protected <T> List<T> extractResultSet(ResultSet rs, TypeReference<T> typeRef) throws Exception {
        return extractResultSet(rs, typeRef.getType());
    }

    /**
     * Extract {@link ResultSet}
     *
     * @param rs   {@link ResultSet}
     * @param type data type
     * @param <T>  data type
     * @return result rows`s data.
     * @throws Exception
     */
    protected <T> List<T> extractResultSet(ResultSet rs, Type type) throws Exception {
        return extractResultSet(rs, type, -1);
    }

    /**
     * Extract {@link ResultSet}
     *
     * @param rs      {@link ResultSet}
     * @param type    data type
     * @param maxRows expected rows count
     * @param <T>     data type
     * @return result rows`s data.
     * @throws Exception
     */
    protected <T> List<T> extractResultSet(ResultSet rs, Class<T> type, int maxRows) throws Exception {
        return extractResultSet(rs, (Type) type, maxRows);
    }

    /**
     * Extract {@link ResultSet}
     *
     * @param rs      {@link ResultSet}
     * @param typeRef {@link TypeReference} data type
     * @param maxRows max rows count expected
     * @param <T>     data type
     * @return result rows`s data.
     * @throws Exception
     */
    protected <T> List<T> extractResultSet(ResultSet rs, TypeReference<T> typeRef, int maxRows)
            throws Exception {
        return extractResultSet(rs, typeRef.getType(), maxRows);
    }

    /**
     * Extract {@link ResultSet}
     *
     * @param rs      {@link ResultSet}
     * @param type    data type
     * @param maxRows max rows count expected
     * @param <T>     data type
     * @return result rows`s data.
     * @throws Exception
     */
    @SuppressWarnings("unchecked")
    protected <T> List<T> extractResultSet(ResultSet rs, Type type, int maxRows)
            throws Exception {
        try {
            List<T> results = new ArrayList<>();
            int rowNum = 0;
            while (rs.next()) {
                results.add((T) newRowMapper(type).mapRow(rs, rowNum++));
                if (maxRows > 0 && rowNum >= maxRows) {
                    break;
                }
            }
            return results;
        } finally {
            JdbcUtils.closeResultSet(rs);
        }
    }

    /**
     * Create {@link RowMapper}
     *
     * @param type data type
     * @param <T>  data type
     * @return {@link RowMapper}
     */
    protected <T> RowMapper<T> newRowMapper(Class<T> type) {
        return newRowMapper((Type) type);
    }

    /**
     * Create {@link RowMapper}
     *
     * @param typeRef {@link TypeReference} data type
     * @param <T>     data type
     * @return {@link RowMapper}
     */
    protected <T> RowMapper<T> newRowMapper(TypeReference<T> typeRef) {
        return newRowMapper(typeRef.getType());
    }

    /**
     * Create {@link RowMapper}
     *
     * @param type data type
     * @param <T>  data type
     * @return {@link RowMapper}
     */
    @SuppressWarnings("unchecked")
    protected <T> RowMapper<T> newRowMapper(Type type) {
        return (rs, rowNum) -> {
            ResultSetMetaData metaData = rs.getMetaData();
            int colNum = metaData.getColumnCount();
            if (colNum == 0) {
                return null;
            }
            JSONObject kvMap = new JSONObject(colNum);
            if (colNum == 1) {
                String colName = JdbcUtils.lookupColumnName(metaData, 1);
                Object colValue = rs.getObject(1);
                if (type instanceof Class) {
                    Class<?> clazz = (Class<?>) type;
                    if (colValue != null && clazz.isAssignableFrom(colValue.getClass())) {
                        return (T) colValue;
                    }
                    if (BeanUtils.isSimpleValueType(clazz)) {
                        if (colValue == null) {
                            return null;
                        }
                        return JSON.parseObject(String.valueOf(colValue), type);
                    }
                }
                kvMap.put(colName, colValue);
            } else {
                for (int i = 1; i <= colNum; i++) {
                    String colName = JdbcUtils.lookupColumnName(metaData, i);
                    Object colValue = rs.getObject(i);
                    kvMap.put(colName, colValue);
                }
            }
            if (kvMap.isEmpty()) {
                return null;
            }
            prettyMapping(type, kvMap);
            if (type instanceof Class) {
                Class<?> clazz = (Class<?>) type;
                if (clazz.isAssignableFrom(Map.class) || clazz.isAssignableFrom(kvMap.getClass())) {
                    return (T) kvMap;
                }
                if (JSONObject.class.isAssignableFrom(clazz)) {
                    JSONObject result = (JSONObject) BeanUtils.instantiateClass(clazz);
                    result.putAll(kvMap);
                    return (T) result;
                }
            }
            return kvMap.toJavaObject(type);
        };
    }

    /**
     * Pretty mapping data
     *
     * @param type    {@link Type} used for mapping
     * @param mapping mapping data
     * @see GenToolKit#editMapping(JSONObject, Function)
     */
    protected void prettyMapping(Type type, JSONObject mapping) {
        if (type instanceof Class && Metadata.class.isAssignableFrom((Class<?>) type)) {
            GenToolKit.editMapping(mapping, entry -> entry.getKey().toLowerCase());
        }
        if (type == TableColumn.class) {
            if (mapping.get("is_nullable") != null) {
                mapping.put("is_nullable", GenToolKit.objectAsBoolean(mapping.get("is_nullable")));
            } else {
                if (mapping.get("nullable") != null) {
                    mapping.put("is_nullable", GenToolKit.objectAsBoolean(mapping.get("nullable")));
                }
            }
            if (mapping.get("is_autoincrement") != null) {
                mapping.put("is_autoincrement", GenToolKit.objectAsBoolean(mapping.get("is_autoincrement")));
            }
        }
    }

    @Override
    public final void afterPropertiesSet() throws Exception {
        if (logger.isDebugEnabled()) {
            logger.debug("Dialect '{}' with '{}' initialized", getDialect().name(), getClass().getSimpleName());
        }
        afterPropertiesReady();
    }

    protected void afterPropertiesReady() throws Exception {
        // do nothing
    }
}
